iT邦幫忙

2026 iThome 鐵人賽

DAY 13
0

今天的主題:讓小怪反擊——順便發現我的遊戲根本不可能輸

到昨天為止,小怪都只會站著挨打。今天讓牠還手,戰鬥才真的是雙向的。

順序是關鍵,不是把反擊加在後面就好

Day 22 畫流程圖的時候,我就標了一個陷阱:

[玩家攻擊] → [敵人死了?] ─是→ [勝利]     ← 死了就不該反擊
                  ↓ 否
             [敵人反擊] → [勇者死了?] ─是→ [失敗]
                  ↓ 否
             [回合 +1]

「敵人死了嗎」必須先判斷,不然會變成「小怪死了還打你一下」。

程式裡用 return 提前結束來保證這件事:

state.enemy.hp = state.enemy.hp - heroDmg;

if (state.enemy.hp <= 0) {
  endGame("win");
  updateScreen();
  return;              // 直接結束,下面的反擊完全不會執行
}

// 敵人還活著,才輪到牠打
const enemyDmg = calcDamage(state.enemy.atk, state.hero.def);

這是 Day 11 學到的第二種 return 用法 —— 不交任何值,只是說「這次到此為止」。

一個狀態要升級:光知道「結束了」不夠

之前 isOver 只記錄「結束了沒」,但現在有兩種結束方式,所以要多一個欄位:

state.result = null;     // null = 進行中 / "win" / "lose"

這是 Day 18 那個教訓的延伸:用旗標記錄狀態本身,比從數值反推可靠。如果只靠「誰的血是 0」來判斷,萬一之後加了「逃跑」「平手」,那套推論就不夠用了。

兩種結局的處理也不一樣:

勝利 失敗
按鈕 變成「鞭屍!!」,還能按 停用
訊息 被擊敗了 + 回合數 倒下了 + 撐了幾回合
紀錄 比對並儲存 不存

失敗時停用按鈕,是因為死掉的人沒有鞭屍的餘地。

而這立刻帶出一個要注意的事:reset() 必須把 attackBtn.disabled 放回 false,不然輸過一次就永遠不能再玩了。這就是 Day 22 那張「重置要還原什麼」表格的價值 —— 每加一個狀態,就要回去看那張表。

然後我發現一件事:勇者根本不可能輸

寫完之後我開始測「失敗」的流程,結果怎麼打都贏。

算一下就知道為什麼:

HP 每次傷害 需要幾次攻擊
勇者 100 14~20 打死小怪要 3~5 次
小怪 60 7~13 打死勇者要 8~15 次

勇者先攻,而且早五六個回合就達標。「失敗」那段程式碼永遠不會被執行。

這讓我卡了一下,因為它引出一個很實際的問題:

一段永遠跑不到的程式碼,算不算寫好了?我要怎麼驗證它是對的?

這在測試工作裡是很常見的處境 —— 某個錯誤處理的分支,正常操作永遠進不去。你不能因為「反正跑不到」就不管它,因為總有一天會跑到。

我的做法是先改數值讓它跑得到:把勇者攻擊力從 20 調到 8,然後真的輸一場,確認失敗流程長什麼樣。

插入 day24.png:勇者倒下了,撐了 11 回合;小怪只剩 1 點血

而且這一場輸得很戲劇化 —— 小怪只剩 1 點血。再一下就贏了。

驗證的結果是:訊息正確、「鞭屍!!」按鈕被鎖起來、紀錄沒有被存進去(失敗不該算紀錄)、state.result 是 "lose"。失敗流程確實是對的。

平衡:我的算法,和我算錯的地方

確認失敗流程沒問題之後,問題變成:那到底該設多少?

我的想法是:先用基準傷害把兩邊「需要幾次攻擊」算到接近,剩下交給亂數決定。

小怪殺勇者:100 / (15 - 5) = 10 次
勇者殺小怪:60 / (8 - 3)  = 12 次

10 和 12,看起來很接近。但實際上 —— 小怪整整早兩回合達標,而 ±3 的浮動不足以翻盤兩個回合的差距。

我從「勇者必勝」跳到了「勇者幾乎必輸」。

要真正勢均力敵,兩邊都該是 10 次:

勇者要 10 次殺死小怪 → 每次要 60/10 = 6 點傷害
6 = atk - 3  →  atk = 9

8 和 9 只差 1。 差這麼一點,結果會差多少?用猜的不行,得實測。

手動打 20 場?不,寫程式讓它自己打

一開始我想的是手動打 20 場記錄輸贏。但算一下:一場要按十幾下,20 場就是兩百多下滑鼠,而且 Day 16 學過 —— 20 場的樣本還是太小。

所以我寫了一個模擬器:

function simulate(heroAtk, times = 1000) {
  let wins = 0;
  for (let i = 0; i < times; i++) {
    let heroHp = 100, enemyHp = 60, turn = 1;
    while (true) {
      enemyHp = enemyHp - calcDamage(heroAtk, 3);
      if (enemyHp <= 0) { wins = wins + 1; break; }
      heroHp = heroHp - calcDamage(15, 5);
      if (heroHp <= 0) { break; }
      turn = turn + 1;
    }
  }
  // 輸出勝率、平均回合數、最快回合數
}

它只跑計算,完全不碰畫面,所以 1000 場一瞬間就結束 —— 這正好呼應 Day 19 學到的:畫面更新才是慢的部分。

這件事有個名字叫自動化測試:把「人重複做很多次」的事交給程式。我在工作上很熟悉這個概念,但今天是第一次自己寫一個。

結果:一點之差,勝率差了 47 個百分點

勇者 atk=8 :勝率 17.5%(175/1000),平均 10.4 回合,最快 9 回合
勇者 atk=9 :勝率 64.9%(649/1000),平均 10.0 回合,最快 8 回合
勇者 atk=10:勝率 97.8%(978/1000),平均  9.0 回合,最快 7 回合
勇者 atk=20:勝率  100%(1000/1000),平均  4.0 回合,最快 3 回合

插入 day24-3.png:主控台的四組模擬結果

攻擊力 8 到 9,勝率從 17.5% 跳到 64.9% —— 差了 47 個百分點。再加 1 點變成 10,又跳到 97.8%。

一個數字差 1,整個遊戲的體感完全不同。如果不是跑模擬,我永遠不會知道這個區間這麼陡。

而且我的推算是錯的,模擬糾正了我

我原本用算式推論 atk = 9 應該是「五五波」,因為兩邊都需要 10 次攻擊。實際跑出來是 64.9%,明顯偏向勇者。

原因是我的算式漏了一件事:勇者先攻。同樣需要 10 次的情況下,勇者的第 10 次會先落下。算式只算了「需要幾次」,沒有算「誰先出手」。

這件事很值得記下來:數學推導給方向,實測給答案。 我的算式把範圍縮小到 8~10 之間是有用的,但要決定最後那個數字,還是得跑。

順便解決了 Day 22 那個問題

Day 22 回顧時我發現一個問題:每場都剛好 4 回合,「最少回合挑戰」根本沒有意義。

看模擬結果的最後兩欄:

攻擊力 平均回合 最快回合 變化空間
20(原本) 4.0 3 幾乎沒有
9 10.0 8 2 回合以上

原本的設定平均 4 回合、最快 3 回合,中間只有一格的空間,難怪每場都一樣。改成 9 之後,平均 10、最快 8,終於有東西可以挑戰了。

一個調整同時解決了兩個問題:讓遊戲有輸的可能,也讓紀錄有意義。這是我第一次感受到「數值設計」不只是填數字,而是在決定整個遊戲的體驗。

最後我把勇者的攻擊力定在 9。64.9% 的勝率對一個小品來說剛好:大多數人會贏,但偶爾會翻船 —— 而正是那偶爾的一次,才讓贏的時候有感覺。如果選 10,97.8% 的勝率等於沒有輸的體驗;如果選 8,17.5% 又太挫折。

插入 day24-2.png:調整後打贏的畫面,勇者只剩 6 點血,撐了 11 回合

上圖是調整後的一場勝利 —— 贏的時候勇者只剩 6 點血。這種「差一點就輸」的感覺,原本的設定裡完全不存在。

但我知道這只是暫時的答案

現在的做法是「先把兩邊基準值調到接近,剩下交給亂數」。它可以用,但沒有多好。

因為之後如果要擴充 —— 不同的怪物需要不同數值、勇者會有不同裝備、還會有增益和減益狀態 —— 那時候就不能再靠手算兩個數字了。真正的遊戲需要一套可以推算的數值系統,而不是一次一次試。

不過那是以後的事。以現在這個「一隻勇者打一隻小怪」的規模,手算加模擬驗證已經夠用了。

小結

今天搞懂的事:

  • 加反擊要注意順序:敵人死了就不該反擊,用 return 提前結束來保證
  • 結束方式超過一種時,要用 result 記錄「怎麼結束的」,不只是「結束了沒」
  • 每新增一個狀態,重置時就要回去對照那張清單
  • 一段永遠跑不到的程式碼,要先想辦法讓它跑得到才能驗證
  • 手動測 20 場太慢而且樣本不足 —— 寫個模擬器跑 1000 場,這就是自動化測試
  • 攻擊力差 1 點,勝率差 47 個百分點;數學推導給方向,實測給答案
  • 我的算式漏了「先攻優勢」,所以推論偏差 —— 系統的行為比算式複雜
  • 好的數值不只決定難度,也決定了「紀錄有沒有挑戰空間」

明天讓被打的時候有感覺 —— Day 17 埋的受擊震動,終於要做了。


上一篇
Day23|把滿血量改成負數,然後一筆假紀錄被永久存了下來
下一篇
Day25|我做出了一個會打人的屍體
系列文
QA 的 JavaScript 初心之路:30 天做出一個能玩的戰鬥小遊戲《勇者 vs 小怪》 共 30 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言